Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 905ced6a4047f3fb4e4b0dec9da7b5adcf161a18


Parents : 3c2979b
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-06T13:47:33-05:00

feat(settings): introduce settings tabs structure and normalization functions for improved settings management

Changes
Diff

diff --git a/meshchatx/src/frontend/js/settings/settingsTabs.js b/meshchatx/src/frontend/js/settings/settingsTabs.js
new file mode 100644
index 00000000..a99d097b
--- /dev/null
+++ b/meshchatx/src/frontend/js/settings/settingsTabs.js
@@ -0,0 +1,65 @@
+// SPDX-License-Identifier: 0BSD
+
+/** @typedef {{ id: string, labelKey: string, descriptionKey: string, sections: string[] }} SettingsTab */
+
+/** @type {SettingsTab[]} */
+export const SETTINGS_TABS = [
+ {
+ id: "general",
+ labelKey: "settings.tabs.general",
+ descriptionKey: "settings.tabs.general_desc",
+ sections: ["language", "appearance", "desktop", "android", "shortcuts"],
+ },
+ {
+ id: "messages",
+ labelKey: "settings.tabs.messages",
+ descriptionKey: "settings.tabs.messages_desc",
+ sections: ["strangerProtection", "messages", "propagation", "stickers", "gifs", "banishment", "telephony"],
+ },
+ {
+ id: "network",
+ labelKey: "settings.tabs.network",
+ descriptionKey: "settings.tabs.network_desc",
+ sections: ["transport", "interfaces", "visualiser", "location", "crawler", "infrastructure", "networkSecurity"],
+ },
+ {
+ id: "nomad",
+ labelKey: "settings.tabs.nomad",
+ descriptionKey: "settings.tabs.nomad_desc",
+ sections: ["archiver", "nomadRenderer"],
+ },
+ {
+ id: "privacy",
+ labelKey: "settings.tabs.privacy",
+ descriptionKey: "settings.tabs.privacy_desc",
+ sections: ["privacyData", "blocked", "auth", "webExposure", "csp"],
+ },
+ {
+ id: "maintenance",
+ labelKey: "settings.tabs.maintenance",
+ descriptionKey: "settings.tabs.maintenance_desc",
+ sections: ["maintenance"],
+ },
+];
+
+export const DEFAULT_SETTINGS_TAB = "general";
+
+/**
+ * @param {string | undefined | null} tabId
+ * @returns {string}
+ */
+export function normalizeSettingsTabId(tabId) {
+ if (tabId && SETTINGS_TABS.some((tab) => tab.id === tabId)) {
+ return tabId;
+ }
+ return DEFAULT_SETTINGS_TAB;
+}
+
+/**
+ * @param {string} sectionKey
+ * @returns {string | null}
+ */
+export function settingsTabForSection(sectionKey) {
+ const tab = SETTINGS_TABS.find((entry) => entry.sections.includes(sectionKey));
+ return tab ? tab.id : null;
+}

diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js
index aa9003b7..cdf69f41 100644
--- a/meshchatx/src/frontend/main.js
+++ b/meshchatx/src/frontend/main.js
@@ -1,4 +1,4 @@
-import { createApp, defineAsyncComponent } from "vue";
+import { createApp } from "vue";
import { createRouter, createWebHashHistory } from "vue-router";
import { createI18n } from "vue-i18n";
import vClickOutside from "click-outside-vue3";
@@ -50,7 +50,7 @@ const router = createRouter({
{
name: "auth",
path: "/auth",
- component: defineAsyncComponent(() => import("./components/auth/AuthPage.vue")),
+ component: () => import("./components/auth/AuthPage.vue"),
},
{
path: "/",
@@ -59,22 +59,22 @@ const router = createRouter({
{
name: "about",
path: "/about",
- component: defineAsyncComponent(() => import("./components/about/AboutPage.vue")),
+ component: () => import("./components/about/AboutPage.vue"),
},
{
name: "interfaces",
path: "/interfaces",
- component: defineAsyncComponent(() => import("./components/interfaces/InterfacesPage.vue")),
+ component: () => import("./components/interfaces/InterfacesPage.vue"),
},
{
name: "interfaces.add",
path: "/interfaces/add",
- component: defineAsyncComponent(() => import("./components/interfaces/AddInterfacePage.vue")),
+ component: () => import("./components/interfaces/AddInterfacePage.vue"),
},
{
name: "interfaces.edit",
path: "/interfaces/edit",
- component: defineAsyncComponent(() => import("./components/interfaces/AddInterfacePage.vue")),
+ component: () => import("./components/interfaces/AddInterfacePage.vue"),
props: {
interface_name: String,
},
@@ -84,213 +84,213 @@ const router = createRouter({
path: "/messages/:destinationHash?",
props: true,
meta: { stableKey: true },
- component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
+ component: () => import("./components/messages/MessagesPage.vue"),
},
{
name: "contacts",
path: "/contacts",
- component: defineAsyncComponent(() => import("./components/contacts/ContactsPage.vue")),
+ component: () => import("./components/contacts/ContactsPage.vue"),
},
{
name: "map",
path: "/map",
meta: { keepAlive: true },
- component: defineAsyncComponent(() => import("./components/map/MapBrowser.vue")),
+ component: () => import("./components/map/MapBrowser.vue"),
},
{
name: "map-popout",
path: "/popout/map",
meta: { popoutType: "map", isPopout: true },
- component: defineAsyncComponent(() => import("./components/map/MapPage.vue")),
+ component: () => import("./components/map/MapPage.vue"),
},
{
name: "messages-popout",
path: "/popout/messages/:destinationHash?",
props: true,
meta: { popoutType: "conversation", isPopout: true },
- component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
+ component: () => import("./components/messages/MessagesPage.vue"),
},
{
name: "network-visualiser",
path: "/network-visualiser",
- component: defineAsyncComponent(() => import("./components/network-visualiser/NetworkVisualiserPage.vue")),
+ component: () => import("./components/network-visualiser/NetworkVisualiserPage.vue"),
},
{
name: "nomadnetwork",
path: "/nomadnetwork/:destinationHash?",
props: true,
meta: { keepAlive: true },
- component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkBrowser.vue")),
+ component: () => import("./components/nomadnetwork/NomadNetworkBrowser.vue"),
},
{
name: "relay-chat",
path: "/relay-chat",
- component: defineAsyncComponent(() => import("./components/relay/RelayChatPage.vue")),
+ component: () => import("./components/relay/RelayChatPage.vue"),
},
{
name: "relay-chat-popout",
path: "/popout/relay-chat/:hubHash/:room?",
props: true,
meta: { popoutType: "relay", isPopout: true },
- component: defineAsyncComponent(() => import("./components/relay/RelayChatPage.vue")),
+ component: () => import("./components/relay/RelayChatPage.vue"),
},
{
name: "archives",
path: "/archives",
- component: defineAsyncComponent(() => import("./components/archives/ArchivesPage.vue")),
+ component: () => import("./components/archives/ArchivesPage.vue"),
},
{
name: "nomadnetwork-popout",
path: "/popout/nomadnetwork/:destinationHash?",
props: true,
meta: { popoutType: "nomad", isPopout: true },
- component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkPage.vue")),
+ component: () => import("./components/nomadnetwork/NomadNetworkPage.vue"),
},
{
name: "propagation-nodes",
path: "/propagation-nodes",
- component: defineAsyncComponent(() => import("./components/propagation-nodes/PropagationNodesPage.vue")),
+ component: () => import("./components/propagation-nodes/PropagationNodesPage.vue"),
},
{
name: "ping",
path: "/ping",
- component: defineAsyncComponent(() => import("./components/ping/PingPage.vue")),
+ component: () => import("./components/ping/PingPage.vue"),
},
{
name: "rncp",
path: "/rncp",
- component: defineAsyncComponent(() => import("./components/rncp/RNCPPage.vue")),
+ component: () => import("./components/rncp/RNCPPage.vue"),
},
{
name: "rnsh",
path: "/rnsh",
- component: defineAsyncComponent(() => import("./components/tools/RNSHManagerPage.vue")),
+ component: () => import("./components/tools/RNSHManagerPage.vue"),
},
{
name: "rnstatus",
path: "/rnstatus",
- component: defineAsyncComponent(() => import("./components/rnstatus/RNStatusPage.vue")),
+ component: () => import("./components/rnstatus/RNStatusPage.vue"),
},
{
name: "rnpath",
path: "/rnpath",
- component: defineAsyncComponent(() => import("./components/tools/RNPathPage.vue")),
+ component: () => import("./components/tools/RNPathPage.vue"),
},
{
name: "rnpath-trace",
path: "/rnpath-trace",
- component: defineAsyncComponent(() => import("./components/tools/RNPathTracePage.vue")),
+ component: () => import("./components/tools/RNPathTracePage.vue"),
},
{
name: "rnprobe",
path: "/rnprobe",
- component: defineAsyncComponent(() => import("./components/rnprobe/RNProbePage.vue")),
+ component: () => import("./components/rnprobe/RNProbePage.vue"),
},
{
name: "translator",
path: "/translator",
- component: defineAsyncComponent(() => import("./components/translator/TranslatorPage.vue")),
+ component: () => import("./components/translator/TranslatorPage.vue"),
},
{
name: "bots",
path: "/bots",
- component: defineAsyncComponent(() => import("./components/tools/BotsPage.vue")),
+ component: () => import("./components/tools/BotsPage.vue"),
},
{
name: "forwarder",
path: "/forwarder",
- component: defineAsyncComponent(() => import("./components/forwarder/ForwarderPage.vue")),
+ component: () => import("./components/forwarder/ForwarderPage.vue"),
},
{
name: "micron-editor",
path: "/micron-editor",
- component: defineAsyncComponent(() => import("./components/micron-editor/MicronEditorPage.vue")),
+ component: () => import("./components/micron-editor/MicronEditorPage.vue"),
},
{
name: "reticulum-config-editor",
path: "/tools/reticulum-config-editor",
- component: defineAsyncComponent(() => import("./components/tools/ReticulumConfigEditorPage.vue")),
+ component: () => import("./components/tools/ReticulumConfigEditorPage.vue"),
},
{
name: "mesh-server",
path: "/mesh-server",
- component: defineAsyncComponent(() => import("./components/page-nodes/PageNodesPage.vue")),
+ component: () => import("./components/page-nodes/PageNodesPage.vue"),
},
{
name: "documentation",
path: "/documentation",
- component: defineAsyncComponent(() => import("./components/docs/DocsPage.vue")),
+ component: () => import("./components/docs/DocsPage.vue"),
},
{
name: "profile.icon",
path: "/profile/icon",
- component: defineAsyncComponent(() => import("./components/profile/ProfileIconPage.vue")),
+ component: () => import("./components/profile/ProfileIconPage.vue"),
},
{
name: "settings",
path: "/settings",
- component: defineAsyncComponent(() => import("./components/settings/SettingsPage.vue")),
+ component: () => import("./components/settings/SettingsPage.vue"),
},
{
name: "identities",
path: "/identities",
- component: defineAsyncComponent(() => import("./components/settings/IdentitiesPage.vue")),
+ component: () => import("./components/settings/IdentitiesPage.vue"),
},
{
name: "blocked",
path: "/blocked",
- component: defineAsyncComponent(() => import("./components/blocked/BlockedPage.vue")),
+ component: () => import("./components/blocked/BlockedPage.vue"),
},
{
name: "tools",
path: "/tools",
- component: defineAsyncComponent(() => import("./components/tools/ToolsPage.vue")),
+ component: () => import("./components/tools/ToolsPage.vue"),
},
{
name: "licenses",
path: "/licenses",
- component: defineAsyncComponent(() => import("./components/licenses/LicensesPage.vue")),
+ component: () => import("./components/licenses/LicensesPage.vue"),
},
{
name: "paper-message",
path: "/tools/paper-message",
- component: defineAsyncComponent(() => import("./components/tools/PaperMessagePage.vue")),
+ component: () => import("./components/tools/PaperMessagePage.vue"),
},
{
name: "sieve-filters",
path: "/tools/sieve-filters",
- component: defineAsyncComponent(() => import("./components/tools/SieveFiltersPage.vue")),
+ component: () => import("./components/tools/SieveFiltersPage.vue"),
},
{
name: "message-blocklist",
path: "/tools/message-blocklist",
- component: defineAsyncComponent(() => import("./components/tools/MessageBlocklistPage.vue")),
+ component: () => import("./components/tools/MessageBlocklistPage.vue"),
},
{
name: "rnode-flasher",
path: "/tools/rnode-flasher",
- component: defineAsyncComponent(() => import("./components/tools/RNodeFlasherPage.vue")),
+ component: () => import("./components/tools/RNodeFlasherPage.vue"),
},
{
name: "repository-server",
path: "/tools/repository-server",
- component: defineAsyncComponent(() => import("./components/tools/RepositoryServerPage.vue")),
+ component: () => import("./components/tools/RepositoryServerPage.vue"),
},
{
name: "debug-logs",
path: "/debug/logs",
- component: defineAsyncComponent(() => import("./components/debug/DebugLogsPage.vue")),
+ component: () => import("./components/debug/DebugLogsPage.vue"),
},
{
name: "call",
path: "/call",
- component: defineAsyncComponent(() => import("./components/call/CallPage.vue")),
+ component: () => import("./components/call/CallPage.vue"),
},
{
name: "call-popout",
path: "/popout/call",
meta: { isPopout: true },
- component: defineAsyncComponent(() => import("./components/call/CallPage.vue")),
+ component: () => import("./components/call/CallPage.vue"),
},
{
name: "changelog",

diff --git a/scripts/dev-local.sh b/scripts/dev-local.sh
index 594d047b..554fbdb0 100644
--- a/scripts/dev-local.sh
+++ b/scripts/dev-local.sh
@@ -17,10 +17,32 @@ cleanup() {
}
trap cleanup EXIT INT TERM
-uv run python -m meshchatx.meshchat &
+uv run python -m meshchatx.meshchat --headless --host 127.0.0.1 --port "$MESHCHAT_PORT" &
BE_PID=$!
-sleep "${DEV_BACKEND_WAIT:-1}"
+BACKEND_URL="https://127.0.0.1:${MESHCHAT_PORT}/api/v1/app/info"
+BACKEND_WAIT_SECS="${DEV_BACKEND_WAIT:-120}"
+BACKEND_POLL_INTERVAL="${DEV_BACKEND_POLL_INTERVAL:-0.5}"
+deadline=$((SECONDS + BACKEND_WAIT_SECS))
+
+echo "[dev] Waiting for backend (HTTPS) on port ${MESHCHAT_PORT}..."
+while (( SECONDS < deadline )); do
+ if ! kill -0 "$BE_PID" 2>/dev/null; then
+ echo "[dev] Backend process exited before becoming ready." >&2
+ wait "$BE_PID" 2>/dev/null || true
+ exit 1
+ fi
+ if curl -fsSk --max-time 2 "$BACKEND_URL" >/dev/null 2>&1; then
+ echo "[dev] Backend ready."
+ break
+ fi
+ sleep "$BACKEND_POLL_INTERVAL"
+done
+
+if ! curl -fsSk --max-time 2 "$BACKEND_URL" >/dev/null 2>&1; then
+ echo "[dev] Backend did not respond on ${BACKEND_URL} within ${BACKEND_WAIT_SECS}s." >&2
+ exit 1
+fi
VITE_HOST="${VITE_DEV_HOST:-127.0.0.1}"
VITE_PORT="${VITE_DEV_PORT:-5173}"

diff --git a/scripts/e2e/start-e2e-stack.sh b/scripts/e2e/start-e2e-stack.sh
index 9ef90032..65762730 100755
--- a/scripts/e2e/start-e2e-stack.sh
+++ b/scripts/e2e/start-e2e-stack.sh
@@ -5,6 +5,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
export E2E_BACKEND_PORT="${E2E_BACKEND_PORT:-18079}"
+export MESHCHAT_NO_HTTPS=1
BACKEND_PORT="$E2E_BACKEND_PORT"
VITE_HOST="${E2E_VITE_HOST:-127.0.0.1}"
VITE_PORT="${E2E_VITE_PORT:-5173}"

diff --git a/tests/backend/test_rnode_support.py b/tests/backend/test_rnode_support.py
index 01fcea38..4b1b9d6e 100644
--- a/tests/backend/test_rnode_support.py
+++ b/tests/backend/test_rnode_support.py
@@ -1,8 +1,6 @@
# SPDX-License-Identifier: 0BSD
-import pytest
-
from meshchatx.src.backend import rnode_support

diff --git a/tests/frontend/BlockedPage.test.js b/tests/frontend/BlockedPage.test.js
index 708e5b69..a3888270 100644
--- a/tests/frontend/BlockedPage.test.js
+++ b/tests/frontend/BlockedPage.test.js
@@ -88,4 +88,55 @@ describe("BlockedPage UI", () => {
await input.setValue("test");
expect(wrapper.vm.searchQuery).toBe("test");
});
+
+ it("supports multi-select mode for bulk unban", async () => {
+ global.api.get = vi.fn().mockImplementation((url, opts) => {
+ if (url === "/api/v1/blocked-destinations")
+ return Promise.resolve({
+ data: {
+ blocked_destinations: [{ destination_hash: "abc123" }, { destination_hash: "def456" }],
+ },
+ });
+ if (url === "/api/v1/reticulum/blackhole") return Promise.resolve({ data: { blackholed_identities: {} } });
+ if (url === "/api/v1/announces") {
+ const hash = opts?.params?.destination_hash;
+ return Promise.resolve({
+ data: {
+ announces: [
+ {
+ destination_hash: hash,
+ display_name: hash === "abc123" ? "User A" : "User B",
+ identity_hash: hash,
+ is_node: false,
+ },
+ ],
+ },
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+ global.api.delete = vi.fn().mockResolvedValue({});
+
+ const wrapper = mountBlockedPage();
+ await flushPromises();
+
+ expect(wrapper.findAll('input[type="checkbox"]').length).toBe(0);
+ await wrapper
+ .findAll("button")
+ .find((b) => b.text() === "common.select")
+ .trigger("click");
+ expect(wrapper.vm.selectMode).toBe(true);
+ expect(wrapper.findAll('input[type="checkbox"]').length).toBeGreaterThan(0);
+
+ wrapper.vm.selectedIdentities = ["abc123", "def456"];
+ await wrapper.vm.$nextTick();
+
+ const liftSelected = wrapper.findAll("button").find((b) => b.text().includes("banishment.lift_selected"));
+ expect(liftSelected).toBeTruthy();
+ await liftSelected.trigger("click");
+ await flushPromises();
+
+ expect(global.api.delete).toHaveBeenCalledTimes(2);
+ expect(wrapper.vm.selectMode).toBe(false);
+ });
});

diff --git a/tests/frontend/NomadNetworkBrowser.test.js b/tests/frontend/NomadNetworkBrowser.test.js
index c3d6ed9f..69d2b10d 100644
--- a/tests/frontend/NomadNetworkBrowser.test.js
+++ b/tests/frontend/NomadNetworkBrowser.test.js
@@ -319,4 +319,19 @@ describe("NomadNetworkBrowser.vue", () => {
await wrapper.vm.$nextTick();
expect(wrapper.findComponent({ name: "NomadNetworkPage" }).props("tabsEnabled")).toBe(true);
});
+
+ it("reorders tabs while dragging", () => {
+ const wrapper = mountBrowser();
+ wrapper.vm.addTab("a".repeat(32), null, "Tab A");
+ wrapper.vm.addTab("b".repeat(32), null, "Tab B");
+ const orderBefore = wrapper.vm.tabs.map((tab) => tab.title);
+
+ wrapper.vm.onTabDragStart(0, { dataTransfer: { effectAllowed: "", setData: vi.fn() } });
+ wrapper.vm.onTabDragOver(2);
+ wrapper.vm.onTabDrop(2);
+
+ expect(wrapper.vm.tabs.map((tab) => tab.title)).not.toEqual(orderBefore);
+ expect(wrapper.vm.tabs[2].title).toBe(orderBefore[0]);
+ expect(wrapper.vm.dragTabIndex).toBeNull();
+ });
});

diff --git a/tests/frontend/settingsTabs.test.js b/tests/frontend/settingsTabs.test.js
new file mode 100644
index 00000000..ce3be093
--- /dev/null
+++ b/tests/frontend/settingsTabs.test.js
@@ -0,0 +1,35 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, expect, it } from "vitest";
+import {
+ DEFAULT_SETTINGS_TAB,
+ normalizeSettingsTabId,
+ SETTINGS_TABS,
+ settingsTabForSection,
+} from "../../meshchatx/src/frontend/js/settings/settingsTabs.js";
+
+describe("settingsTabs", () => {
+ it("defaults to general tab", () => {
+ expect(DEFAULT_SETTINGS_TAB).toBe("general");
+ expect(normalizeSettingsTabId(undefined)).toBe("general");
+ expect(normalizeSettingsTabId("invalid")).toBe("general");
+ });
+
+ it("normalizes valid tab ids", () => {
+ expect(normalizeSettingsTabId("privacy")).toBe("privacy");
+ });
+
+ it("maps sections to tabs", () => {
+ expect(settingsTabForSection("appearance")).toBe("general");
+ expect(settingsTabForSection("messages")).toBe("messages");
+ expect(settingsTabForSection("archiver")).toBe("nomad");
+ });
+
+ it("includes every section exactly once", () => {
+ const seen = SETTINGS_TABS.flatMap((tab) => tab.sections);
+ expect(new Set(seen).size).toBe(seen.length);
+ expect(seen).toContain("language");
+ expect(seen).toContain("networkSecurity");
+ expect(seen).toContain("maintenance");
+ });
+});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────